Skip to content

feat(android): support ActivityResultContracts in native modules - #57798

Open
matinzd wants to merge 7 commits into
react:mainfrom
matinzd:feat/permission_contracts_android
Open

feat(android): support ActivityResultContracts in native modules#57798
matinzd wants to merge 7 commits into
react:mainfrom
matinzd:feat/permission_contracts_android

Conversation

@matinzd

@matinzd matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary:

Rendered readme can be found here.

Bare React Native has no way for a native module to use AndroidX ActivityResultContracts. Modules are stuck with ActivityEventListener and self-assigned int request codes. On Android 14+ some contracts (e.g. Health Connect's permission contract) produce a synthetic intent that only an ActivityResultRegistry can service, so the classic startActivityForResult path fails with ActivityNotFoundException outright.

Libraries work around this by demanding glue code in the consumer's MainActivity (e.g. HealthConnectPermissionDelegate.setPermissionDelegate(this)) or by shipping a transparent Activity in their manifest, which cuts against Google's single-activity guidance (matinzd/react-native-health-connect#266, #33639, #36377). Expo solved this with registerActivityContracts; bare RN has no equivalent.

ReactActivity already extends ComponentActivity, so it already owns a real ActivityResultRegistry and routes results into it. Core just needs to hand modules a path to that registry:

private val getContent = reactContext.registerForActivityResult(
    ActivityResultContracts.GetContent()) { uri -> ... }

getContent.launch("image/*")

Design notes:

  • API mirrors ComponentActivity.registerForActivityResult and returns the real androidx.activity.result.ActivityResultLauncher<I>.
  • Modules register before an Activity exists (they're created lazily), so the returned launcher binds to the registry on onHostResume, queues a launch() issued while unbound, and rebinds under the same key after Activity recreation.
  • Keys are the contract's fully-qualified class name. Duplicate registrations throw at registration time; an owner-scoped overload is the escape hatch.
  • No changes to ReactActivity/ReactActivityDelegate/ReactDelegate, no new Gradle dependency, no manifest changes, no forked registry. ActivityEventListener is untouched.
  • Known limitation: on process death, AndroidX redelivers the pending result under the same key, but the module's in-flight state (typically a Promise) died with the JS context.

Demo: SampleTurboModule.requestSamplePermission() (CAMERA), surfaced in rn-tester's SampleTurboModule screen under an Android-only section.

Changelog:

[ANDROID] [ADDED] - Native modules can register AndroidX ActivityResultContracts via ReactContext.registerForActivityResult, with no changes to the consumer's MainActivity

Test Plan:

  • ./gradlew :packages:react-native:ReactAndroid:compileDebugKotlin passes; codegen emits requestSamplePermission into NativeSampleTurboModuleSpec.
  • Verified in rn-tester on Android, TurboModule example, requestSamplePermission:
    • grant resolves true; deny or back resolves false
    • repeat call works
  • Flow, ESLint, prettier clean.

Example App Recording

Screen.Recording.2026-08-03.at.15.27.08.mov

matinzd and others added 2 commits August 3, 2026 13:13
…ntext

Adds com.facebook.react.activityresult with a ReactActivityResultCaller
that registers AndroidX ActivityResultContracts against the host
Activity's own ActivityResultRegistry (ReactActivity already extends
ComponentActivity, so it is an ActivityResultRegistryOwner). No changes
to consumers' MainActivity, no manifest entries, no forked registry.

- Registration is legal at any time: modules are created lazily, so the
  returned launcher is a deferred wrapper that binds to the registry on
  onHostResume, queues a single launch issued while unbound, and rebinds
  under the same key after Activity recreation.
- Keys are the contract's fully-qualified class name; duplicate
  registrations throw at registration time, with an owner-scoped
  overload as the escape hatch.
- ReactContext gains registerForActivityResult convenience methods
  mirroring ComponentActivity, plus getActivityResultCaller().
- ActivityEventListener dispatch is untouched; results flow through
  ComponentActivity's existing onActivityResult /
  onRequestPermissionsResult into its registry.

Demo: SampleTurboModule.requestSamplePermission (CAMERA) wired into
rn-tester's SampleTurboModuleExample under an Android-only section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 3, 2026
@matinzd matinzd changed the title feat: initial support for permission contracts [WIP] feat(android): support for permission contracts Aug 3, 2026
@matinzd matinzd changed the title feat(android): support for permission contracts feat(android): support ActivityResultContracts in native modules Aug 3, 2026
@matinzd
matinzd marked this pull request as ready for review August 3, 2026 12:13
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Aug 3, 2026
@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

This is my decisions and how I planned implementing this with Claude. Comments are welcome!

https://gist.github.com/matinzd/5cac3ef1811efc9817f055b8d27692f7

@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Not sure how to update ReactAndroid.api tho? Are there any scripts or gradle tasks to do this? @cortinico

@matinzd
matinzd marked this pull request as draft August 3, 2026 12:56
@Abbondanzo

Abbondanzo commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@matinzd we can generate it after importing, I don't believe there's a public Gradle script for it

@matinzd
matinzd marked this pull request as ready for review August 3, 2026 13:31
@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

we can generate it after importing, I don't believe there's a public Gradle script for it

Yep. I saw the command in other PRs using buck2!

The PR is now ready to review. Can you please approve the jobs to run?

@matinzd
matinzd requested a review from Abbondanzo August 3, 2026 14:05
@fabriziocucci

fabriziocucci commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Thanks for tackling this @matinzd, the ActivityResultContract gap is real and Health Connect is a good example.

A few things I think are worth sorting first, mostly around lifecycle and threading, though I might be missing some context:

  1. Threading. As far as I can tell a TurboModule field initializer and Promise bodies run on the JS or native-modules thread rather than the UI thread, but ActivityResultRegistry.register and onLaunch are @MainThread and touch non-thread-safe maps. It looks like register (ReactActivityResultCallerImpl.kt) and launch (DeferredActivityResultLauncher.kt) run on the caller thread today, so I think this could crash or misbehave depending on the device. I think both would need to hop to the UI thread, unless I am misreading where they run.

  2. Rebinding to the current Activity. It seems the launcher rebinds only when it is not already bound and does not check whether the bound registry is still current. With multi-activity nav, or the order where the new activity's onHostResume fires before the old one's onHostDestroy, I think it stays bound to the old dead registry. onHostDestroy for the old activity also seems to be skipped by the activity === currentActivity guard, so unbind may never run. If that is right, a launch from the new screen would dispatch into the old activity, results land in the wrong place and the old activity leaks. The single-activity config-change path probably does not hit this since it is the serial order. I think onHostResume might need to always rebind to the current registry rather than gate on isBound.

  3. Keying by the contract class name. It seems two libraries both using a stock contract like GetContent or RequestPermission would get the same key, so the second register throws at field-init and that whole module fails to construct. I think AndroidX auto-generates keys for this reason. The owner-scoped overload helps but the default steers everyone into the collision. This one feels like a design call, so curious what you and the core team think, maybe default to owner-scoped or a caller-provided key with the FQCN as opt-in.

On testing, it looks like the current coverage is mostly the sample module wiring, so the lifecycle and threading paths above are not really exercised. It would help to add tests for those paths, mainly the rebind case (a launch after an Activity swap should hit the new registry) and the main-thread requirement for register and launch.

@matinzd

matinzd commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @fabriziocucci!

  1. Keying by the contract class name. It seems two libraries both using a stock contract like GetContent or RequestPermission would get the same key, so the second register throws at field-init and that whole module fails to construct. I think AndroidX auto-generates keys for this reason. The owner-scoped overload helps but the default steers everyone into the collision. This one feels like a design call, so curious what you and the core team think, maybe default to owner-scoped or a caller-provided key with the FQCN as opt-in.

Sorry, that slipped through. Claude made a mistake, and it’s good that you caught it.

I’m thinking of deriving the key from the FQCN of the native module combined with the contract FQCN. We could throw an error if the same contract is registered twice from the same module, prompting users to use the overload that accepts a caller-provided key instead.

E.g:

// Throws: MyModule already registered a launcher for androidx...GetContent.
private val pickAvatar = ctx.registerForActivityResult(this, GetContent()) { }
private val pickBanner = ctx.registerForActivityResult(this, GetContent()) { }

// Fix:
private val pickAvatar = ctx.registerForActivityResult("avatar", GetContent()) { }
private val pickBanner = ctx.registerForActivityResult("banner", GetContent()) { }

or we can just silently add an index based key based on each module to avoid collision for e.g:

// key = "com.some.image.lib.ImageModule:androidx...GetContent#0"
// key = "com.some.image.lib.ImageModule:androidx...GetContent#1"

What do you think? (Not sure who to tag though from the core team)

On testing, it looks like the current coverage is mostly the sample module wiring, so the lifecycle and threading paths above are not really exercised. It would help to add tests for those paths, mainly the rebind case (a launch after an Activity swap should hit the new registry) and the main-thread requirement for register and launch.

For the rebind case, I tried adding a setTimeout locally when requesting a contract/permission, then used back button to close and put the app in the background. As soon as I opened the app the rebind happened and the picker showed up (Didn't check if the promise was resolved though because in that case the js context is already invalidated).

For the rest, I need to create an example app in order to test those cases. I will let you know when that's ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants